Coverage Report

Created: 2026-08-07 16:19

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
D:\a\cssh-rs\cssh-rs\cssh-rs-core\src\lib.rs
Line
Count
Source
1
//! Cross-platform cluster SSH tool
2
3
#![deny(clippy::implicit_return)]
4
#![allow(clippy::needless_return, clippy::doc_overindented_list_items)]
5
#![warn(missing_docs)]
6
#![doc(html_no_source)]
7
#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
8
9
use std::fs::{create_dir, File};
10
use std::mem;
11
12
use log::warn;
13
use registry::{key, value, Data, Hive, Security};
14
use simplelog::{format_description, ConfigBuilder, LevelFilter, WriteLogger};
15
use windows::core::PWSTR;
16
use windows::Win32::Foundation::HWND;
17
use windows::Win32::System::Threading::{PROCESS_INFORMATION, STARTUPINFOW};
18
19
#[cfg(test)]
20
use mockall::automock;
21
22
pub mod cli;
23
pub mod client;
24
pub mod daemon;
25
pub mod utils;
26
27
use utils::windows::WindowsApi;
28
29
/// CLSID identifying `conhost.exe` in the registry.
30
///
31
/// As used in Windows Terminal:
32
/// <https://github.com/microsoft/terminal/blob/v1.22.3232.0/src/propslib/DelegationConfig.hpp#L105>
33
const CLSID_CONHOST: &str = "{B23D10C0-E52E-411E-9D5B-C09FDF709C7D}";
34
/// Registry path where `DelegationConsole` and `DelegationTerminal` registry keys are stored.
35
///
36
/// These registry keys store the configuration value for the default terminal application.
37
const DEFAULT_TERMINAL_APP_REGISTRY_PATH: &str = r"Console\%%Startup";
38
/// `DelegationConsole` registry key.
39
///
40
/// As used in Windows Terminal:
41
/// <https://github.com/microsoft/terminal/blob/v1.22.3232.0/src/propslib/DelegationConfig.cpp#L29>
42
const DELEGATION_CONSOLE: &str = "DelegationConsole";
43
/// `DelegationTerminal` registry key.
44
///
45
/// As used in Windows Terminal:
46
/// <https://github.com/microsoft/terminal/blob/v1.22.3232.0/src/propslib/DelegationConfig.cpp#L30>
47
const DELEGATION_TERMINAL: &str = "DelegationTerminal";
48
49
/// Trait for registry operations to enable mocking in tests
50
#[cfg_attr(test, automock)]
51
pub trait Registry {
52
    /// Return the string value at `path`/`name`, or `None` if the key or value
53
    /// does not exist.
54
    fn get_registry_string_value(&self, path: &str, name: &str) -> Option<String>;
55
    /// Set a string value, creating the key if it does not exist. Return whether it succeeded.
56
    fn set_registry_string_value(&self, path: &str, name: &str, value: &str) -> bool;
57
    /// Delete a value; an already-absent value counts as success.
58
    fn delete_registry_string_value(&self, path: &str, name: &str) -> bool;
59
    /// Return whether the registry key at `path` exists.
60
    fn registry_key_exists(&self, path: &str) -> bool;
61
    /// Delete the registry key at `path` recursively. Return whether it succeeded.
62
    fn delete_registry_key(&self, path: &str) -> bool;
63
}
64
65
/// Default implementation of Registry trait that performs actual Windows registry API calls
66
pub struct DefaultRegistry;
67
68
#[cfg_attr(coverage_nightly, coverage(off))]
69
impl Registry for DefaultRegistry {
70
    fn get_registry_string_value(&self, path: &str, name: &str) -> Option<String> {
71
        let key = Hive::CurrentUser.open(path, Security::Read).ok()?;
72
        match key.value(name) {
73
            Ok(Data::String(value)) => return Some(value.to_string_lossy()),
74
            Ok(_) => panic!("Expected string data for {name} registry value"),
75
            Err(value::Error::NotFound(_, _)) => return None,
76
            Err(err) => {
77
                warn!("Failed to read {} value from registry: {}", name, err);
78
                return None;
79
            }
80
        }
81
    }
82
83
    fn set_registry_string_value(&self, path: &str, name: &str, value: &str) -> bool {
84
        // create() opens the key or makes it when absent, forcing conhost on a fresh profile.
85
        match Hive::CurrentUser.create(path, Security::Read | Security::Write) {
86
            Ok(key) => match key.set_value::<String>(
87
                name.to_owned(),
88
                &Data::String(value.to_owned().try_into().unwrap()),
89
            ) {
90
                Ok(()) => return true,
91
                Err(err) => {
92
                    warn!(
93
                        "Failed to set registry value {} to {}: {}",
94
                        name, value, err
95
                    );
96
                    return false;
97
                }
98
            },
99
            Err(err) => {
100
                warn!("Failed to open or create registry key {}: {}", path, err);
101
                return false;
102
            }
103
        }
104
    }
105
106
    fn delete_registry_string_value(&self, path: &str, name: &str) -> bool {
107
        let key = match Hive::CurrentUser.open(path, Security::Read | Security::Write) {
108
            Ok(key) => key,
109
            // A missing key means the value is already absent; any other error
110
            // (access denied, transient failure) must not masquerade as success.
111
            Err(key::Error::NotFound(_, _)) => return true,
112
            Err(err) => {
113
                warn!(
114
                    "Failed to open registry key {} to delete {}: {}",
115
                    path, name, err
116
                );
117
                return false;
118
            }
119
        };
120
        match key.delete_value(name) {
121
            Ok(()) => return true,
122
            Err(value::Error::NotFound(_, _)) => return true,
123
            Err(err) => {
124
                warn!("Failed to delete registry value {}: {}", name, err);
125
                return false;
126
            }
127
        }
128
    }
129
130
    fn registry_key_exists(&self, path: &str) -> bool {
131
        return Hive::CurrentUser.open(path, Security::Read).is_ok();
132
    }
133
134
    fn delete_registry_key(&self, path: &str) -> bool {
135
        match Hive::CurrentUser.delete(path, true) {
136
            Ok(()) => return true,
137
            Err(err) => {
138
                warn!("Failed to delete registry key {}: {}", path, err);
139
                return false;
140
            }
141
        }
142
    }
143
}
144
145
/// Return the Window Handle [HWND] for the foreground window associated with the given `process_id`.
146
///
147
/// If multiple foreground windows are associated with the given `process_id` it is undefined which [HWND] gets returned.
148
///
149
/// # Arguments
150
///
151
/// * `windows_api` - Windows API operations implementation
152
/// * `process_id` - ID of the process for which to retrieve the window handle.
153
///
154
/// # Returns
155
///
156
/// The Window Handle [HWND] for the window associated with the given `process_id`.
157
5
pub fn get_console_window_handle<W: WindowsApi>(windows_api: &W, process_id: u32) -> HWND {
158
5
    return windows_api.get_window_handle_for_process(process_id);
159
5
}
160
161
/// Create process with command line using the provided API (testable version)
162
///
163
/// # Arguments
164
///
165
/// * `api` - Windows API operations implementation
166
/// * `application` - Application name including file extension
167
/// * `command_line` - UTF-16 encoded command line
168
///
169
/// # Returns
170
///
171
/// [PROCESS_INFORMATION] of the spawned process or None if failed
172
3
pub fn create_process<W: WindowsApi>(
173
3
    api: &W,
174
3
    application: &str,
175
3
    command_line: &[u16],
176
3
) -> Option<PROCESS_INFORMATION> {
177
3
    let mut startupinfo = STARTUPINFOW {
178
3
        cb: mem::size_of::<STARTUPINFOW>() as u32,
179
3
        ..Default::default()
180
3
    };
181
3
    let mut process_information = PROCESS_INFORMATION::default();
182
3
    let mut cmd_line = command_line.to_vec();
183
3
    let command_line_ptr = PWSTR(cmd_line.as_mut_ptr());
184
185
3
    match api.create_process_raw(
186
3
        application,
187
3
        command_line_ptr,
188
3
        &mut startupinfo,
189
3
        &mut process_information,
190
3
    ) {
191
2
        Ok(()) => return Some(process_information),
192
1
        Err(_) => return None,
193
    }
194
3
}
195
196
/// Trait for file system operations to enable mocking in tests
197
#[cfg_attr(test, automock)]
198
pub trait FileSystem {
199
    /// Create a directory
200
    fn create_directory(&self, path: &str) -> bool;
201
    /// Create a log file
202
    fn create_log_file(&self, filename: &str) -> bool;
203
}
204
205
/// Default implementation of FileSystem trait that performs actual file system operations
206
pub struct ProductionFileSystem;
207
208
#[cfg_attr(coverage_nightly, coverage(off))]
209
impl FileSystem for ProductionFileSystem {
210
    fn create_directory(&self, path: &str) -> bool {
211
        return create_dir(path).is_ok() || std::path::Path::new(path).exists();
212
    }
213
214
    fn create_log_file(&self, filename: &str) -> bool {
215
        return File::create(filename).is_ok();
216
    }
217
}
218
219
/// Guard that configures `conhost.exe` as the default terminal application and
220
/// fully reverts its changes when dropped.
221
///
222
/// Restoration is exact: values the guard overwrote are set back, values it
223
/// created are deleted, and a startup key the guard had to create is removed.
224
pub struct WindowsSettingsDefaultTerminalApplicationGuard<R: Registry> {
225
    /// Whether the guard changed the registry and must undo it on drop.
226
    changed: bool,
227
    /// Whether the startup key existed before the guard; if not, drop deletes it.
228
    key_existed: bool,
229
    /// `DelegationConsole` before the guard overwrote it, or `None` if it was absent.
230
    old_windows_terminal_console: Option<String>,
231
    /// `DelegationTerminal` before the guard overwrote it, or `None` if it was absent.
232
    old_windows_terminal_terminal: Option<String>,
233
    /// Registry operations trait
234
    registry: R,
235
}
236
237
impl<R: Registry> WindowsSettingsDefaultTerminalApplicationGuard<R> {
238
    /// Create a new guard, forcing `conhost.exe` as the default terminal application.
239
    ///
240
    /// # Arguments
241
    ///
242
    /// * `registry` - Registry operations implementation
243
    ///
244
    /// # Returns
245
    ///
246
    /// A new guard that reverts its registry changes on drop.
247
10
    pub fn new_with_registry(registry: R) -> Self {
248
10
        let key_existed = registry.registry_key_exists(DEFAULT_TERMINAL_APP_REGISTRY_PATH);
249
10
        let old_windows_terminal_console = registry
250
10
            .get_registry_string_value(DEFAULT_TERMINAL_APP_REGISTRY_PATH, DELEGATION_CONSOLE);
251
10
        let old_windows_terminal_terminal = registry
252
10
            .get_registry_string_value(DEFAULT_TERMINAL_APP_REGISTRY_PATH, DELEGATION_TERMINAL);
253
254
10
        let already_conhost = old_windows_terminal_console.as_deref() == Some(CLSID_CONHOST)
255
3
            && old_windows_terminal_terminal.as_deref() == Some(CLSID_CONHOST);
256
257
10
        if !already_conhost {
258
9
            registry.set_registry_string_value(
259
9
                DEFAULT_TERMINAL_APP_REGISTRY_PATH,
260
9
                DELEGATION_CONSOLE,
261
9
                CLSID_CONHOST,
262
9
            );
263
9
            registry.set_registry_string_value(
264
9
                DEFAULT_TERMINAL_APP_REGISTRY_PATH,
265
9
                DELEGATION_TERMINAL,
266
9
                CLSID_CONHOST,
267
9
            );
268
9
        
}1
269
270
10
        return WindowsSettingsDefaultTerminalApplicationGuard {
271
10
            changed: !already_conhost,
272
10
            key_existed,
273
10
            old_windows_terminal_console,
274
10
            old_windows_terminal_terminal,
275
10
            registry,
276
10
        };
277
10
    }
278
279
    /// Restore `name` to its pre-guard value, or delete it if it was absent.
280
16
    fn restore_value(&self, name: &str, previous: &Option<String>) {
281
16
        match previous {
282
4
            Some(value) => {
283
4
                self.registry.set_registry_string_value(
284
4
                    DEFAULT_TERMINAL_APP_REGISTRY_PATH,
285
4
                    name,
286
4
                    value,
287
4
                );
288
4
            }
289
12
            None => {
290
12
                self.registry
291
12
                    .delete_registry_string_value(DEFAULT_TERMINAL_APP_REGISTRY_PATH, name);
292
12
            }
293
        };
294
16
    }
295
}
296
297
impl WindowsSettingsDefaultTerminalApplicationGuard<DefaultRegistry> {
298
    /// Create a new guard with production registry operations
299
6
    pub fn new() -> Self {
300
6
        return Self::new_with_registry(DefaultRegistry);
301
6
    }
302
}
303
304
impl<R: Registry> Default for WindowsSettingsDefaultTerminalApplicationGuard<R>
305
where
306
    R: Default,
307
{
308
0
    fn default() -> Self {
309
0
        return Self::new_with_registry(R::default());
310
0
    }
311
}
312
313
impl Default for DefaultRegistry {
314
0
    fn default() -> Self {
315
0
        return DefaultRegistry;
316
0
    }
317
}
318
319
impl<R: Registry> Drop for WindowsSettingsDefaultTerminalApplicationGuard<R> {
320
    /// Revert every registry change the guard made.
321
10
    fn drop(&mut self) {
322
10
        if !self.changed {
323
1
            return;
324
9
        }
325
        // The guard created the startup key; deleting it removes the values too.
326
9
        if !self.key_existed {
327
1
            self.registry
328
1
                .delete_registry_key(DEFAULT_TERMINAL_APP_REGISTRY_PATH);
329
1
            return;
330
8
        }
331
8
        self.restore_value(DELEGATION_CONSOLE, &self.old_windows_terminal_console);
332
8
        self.restore_value(DELEGATION_TERMINAL, &self.old_windows_terminal_terminal);
333
10
    }
334
}
335
336
/// Launch the given console application with the given arguments as a new detached process with its own console window.
337
///
338
/// Input/Output handles are not being inherited.
339
/// Whichever default terminal application is configured in the windows system settings will be used
340
/// to host the application (i.e. create the window).
341
///
342
/// # Arguments
343
///
344
/// * `api`                 - Windows API implementation
345
/// * `application`         - Application name including file extension (`.exe`).
346
///                           If the application is not in the `PATH` environment variable,
347
///                           the full path must be specified.
348
/// * `args`                - List of arguments to the application.
349
/// * `with_keyboard_focus` - Whether the new console window should take foreground focus
350
///                           when it appears. Pass `false` when spawning child consoles
351
///                           that must not steal focus from the calling process.
352
///
353
/// # Returns
354
///
355
/// [PROCESS_INFORMATION] of the spawned process.
356
10
pub fn spawn_console_process<W: WindowsApi>(
357
10
    api: &W,
358
10
    application: &str,
359
10
    args: Vec<String>,
360
10
    with_keyboard_focus: bool,
361
10
) -> Option<PROCESS_INFORMATION> {
362
10
    return api.create_process_with_args(application, args, with_keyboard_focus);
363
10
}
364
365
/// Return the path to the currently running executable.
366
///
367
/// Used when spawning child daemon/client consoles so that they invoke the same
368
/// binary that is currently running, regardless of how the user has named the
369
/// executable on disk. Hard-coding `cssh-rs.exe` would break any deployment that
370
/// renames the binary (e.g. release artifacts that embed the version number).
371
///
372
/// # Returns
373
///
374
/// The current executable path as a UTF-8 string. The conversion is lossy if
375
/// the path contains non-UTF-8 code units.
376
///
377
/// # Panics
378
///
379
/// Panics if `std::env::current_exe()` fails. The standard library only
380
/// returns an error in highly unusual circumstances (e.g. the executable has
381
/// been deleted while running); the caller cannot meaningfully recover.
382
13
pub fn current_exe_path() -> String {
383
13
    return std::env::current_exe()
384
13
        .expect("Failed to determine current executable path")
385
13
        .to_string_lossy()
386
13
        .into_owned();
387
13
}
388
389
/// Initialize the logger.
390
///
391
/// Makes sure a `logs` directory exists in the current working directory.
392
/// Log filename format: `<utc-time-of-executable-start>_<name>.log`.
393
/// Configures [log_panics].
394
///
395
/// # Arguments
396
///
397
/// * `name` - Will be part of the log filename.
398
0
pub fn init_logger(name: &str) {
399
0
    init_logger_with_fs(&ProductionFileSystem, name);
400
0
}
401
402
/// Initialize the logger with the provided file system operations.
403
///
404
/// # Arguments
405
///
406
/// * `fs` - File system operations implementation
407
/// * `name` - Will be part of the log filename
408
9
pub fn init_logger_with_fs<F: FileSystem>(fs: &F, name: &str) {
409
9
    let utc_now = chrono::offset::Utc::now()
410
9
        .format("%Y-%m-%d_%H-%M-%S.%f")
411
9
        .to_string();
412
413
9
    fs.create_directory("logs");
414
415
9
    let filename = format!("logs/{utc_now}_{name}.log");
416
9
    if fs.create_log_file(&filename) {
417
7
        if let Ok(
file0
) = File::create(&filename) {
418
0
            let _ = WriteLogger::init(
419
0
                LevelFilter::Debug,
420
0
                ConfigBuilder::new()
421
0
                    .set_time_format_custom(format_description!(
422
0
                        "[hour]:[minute]:[second].[subsecond]"
423
0
                    ))
424
0
                    .build(),
425
0
                file,
426
0
            );
427
0
            log_panics::init();
428
7
        }
429
2
    }
430
9
}
431
432
/// Detect if application was launched from Windows Explorer (GUI) vs command line using the provided console API.
433
///
434
/// Returns true if launched from GUI (separate console), false if from existing console.
435
/// Based on: <https://devblogs.microsoft.com/oldnewthing/20160125-00/?p=92922>
436
///
437
/// # Arguments
438
///
439
/// * `windows_api` - Windows API operations implementation
440
///
441
/// # Returns
442
///
443
/// * `true` - Application was launched from GUI (Explorer, double-click, etc.)
444
/// * `false` - Application was launched from existing console (command line)
445
12
pub fn is_launched_from_gui<W: WindowsApi>(windows_api: &W) -> bool {
446
12
    return windows_api.get_console_attached_process_count() == 1;
447
12
}
448
449
#[cfg(test)]
450
#[path = "./tests/test_lib.rs"]
451
mod test_lib;